iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0

前言:填上那個空括號

昨天結束時,add.wasm 住進了 SvelteKit 專案,instantiateStreaming 的第二個參數還是一個空的 {},因為它什麼都不跟宿主要。今天三件事:讓它開口要一樣東西、讓一段字串真的進去再出來、看它的狀態住在哪裡。三件事其實是同一道邊界的三個方向:它跟外面之間只有這一道,三個方向都是你說了算。全部在昨天的專案裡改。

第一步:它要什麼,你就得給什麼

crate/src/lib.rs,在 add 上面加一個 extern 區塊,add 算完喊一聲:

// 這是它「要」的東西。import 表上會多一行 console.log,宿主不給就起不來。
#[link(wasm_import_module = "console")]
unsafe extern "C" {
    fn log(x: i32);
}

#[unsafe(no_mangle)]
pub extern "C" fn add(x: i32, y: i32) -> i32 {
    let sum = x + y;
    unsafe { log(sum) };
    sum
}

#[link(wasm_import_module = "console")] 那行:模組名不寫預設是 env,這裡刻意叫 console,JS 給的東西形狀才會跟 import 表一模一樣。

npm run wasm,然後 wasm-tools print src/lib/add.wasm,最上面多了一行:

  (import "console" "log" (func $_ZN3add3log17h19bbd718d3781117E (;0;) (type 0)))

import 表從空的變成一行。檔案 456 B,比昨天多 64 B,就是這行的價錢。

src/lib/wasm.ts 改一個地方,昨天的 {} 填上:

const imports = { console: { log: (x: number) => console.log(x) } };
const { instance } = await WebAssembly.instantiateStreaming(fetch(addUrl), imports);
// export 表今天會長,改成標整張表;AddExports 是自己寫的介面,先只有 add。
const wasm = instance.exports as unknown as AddExports;
export const add = wasm.add;

重新整理,頁面還是 7 + 35 = 42,Console 裡多印一個 42:那一行是 wasm 叫的。它不認識 console 這個字,它只知道 import 表上有一個 console.log 收一個 i32,你給了一支函式,它就叫。你給它什麼,它就只有什麼。

壞掉的樣子。imports 換回昨天的 {}

Uncaught (in promise) TypeError: WebAssembly.instantiate(): Import #0 "console": module is not an object or function

{ console: {} },模組有了、函式沒給:

Uncaught (in promise) LinkError: WebAssembly.instantiate(): Import #0 "console" "log": function import requires a callable

兩個都是建實例那一刻就炸,不是等到叫了才炸。它要的東西列在 import 表上,少給一個就起不來。 這句以後換宿主的時候會再遇到一次,到時候卡住的就是這一行。

第二步:資料怎麼進出

一個數字進出,靠參數跟回傳值就夠了。一段字串呢?它沒有「JS 字串」這種東西,它只有一塊線性記憶體,就是 export 表上那個 memory,前天 print 裡 (memory (;0;) 16) 宣告的那塊。所以字串要先變成 bytes,寫進那塊記憶體,再把「位置」跟「長度」告訴它。

lib.rs 再加三支,貼兩支(還有一支 dealloc 把位置還回去,形狀跟 alloc 對稱,不貼):

use std::alloc::{alloc as rust_alloc, dealloc as rust_dealloc, Layout};

// 跟它要一塊位置,JS 把 bytes 寫進去。回傳的指標在 JS 那邊就是一個整數。
#[unsafe(no_mangle)]
pub extern "C" fn alloc(len: usize) -> *mut u8 {
    match Layout::from_size_align(len, 1) {
        Ok(layout) if len > 0 => unsafe { rust_alloc(layout) },
        _ => std::ptr::null_mut(),
    }
}

// 從 ptr 讀 len 個 bytes,算一個 32 位元的雜湊(FNV-1a)。
// 回傳 u32 是故意的:等一下你會看到 JS 把它讀成什麼。
#[unsafe(no_mangle)]
pub unsafe extern "C" fn hash(ptr: *const u8, len: usize) -> u32 {
    let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
    let mut h: u32 = 0x811c9dc5;
    for &b in bytes {
        h ^= b as u32;
        h = h.wrapping_mul(0x01000193);
    }
    h
}

npm run wasm,檔案變 17,717 B。多了一支 alloc,Rust 就把整個記憶體配置器帶進來了,程式碼段從 9 B 變 12 KB、56 支函式。昨天問的「行李是誰放的」,這次你親眼看到是誰。

wasm.ts 先把 AddExports 補上 memoryallocdeallochash,再加一支,四個動作:

export function hash(text: string): number {
	const bytes = new TextEncoder().encode(text); // 先變成 bytes,wasm 不認識 JS 字串
	const ptr = wasm.alloc(bytes.length); //         跟它要一塊位置
	new Uint8Array(wasm.memory.buffer, ptr, bytes.length).set(bytes); // 寫進去
	const h = wasm.hash(ptr, bytes.length) >>> 0; // 叫它算;>>> 0 不是裝飾,等一下會講
	wasm.dealloc(ptr, bytes.length);
	return h;
}

wasm.memory.buffer 是那塊記憶體底下的 ArrayBuffer,用 Uint8Array 在上面開一個 view 就能讀寫。頁面放一個輸入框,打字就重算:輸入 wasm,得到 2784415053

壞掉②,不報錯的那種。>>> 0 拿掉,同樣輸入 wasm,得到 -1510552243。核心 wasm 沒有無號型別:Rust 說 u32,到了 wasm 就只是 i32,JS 讀到的是有號的,超過 2³¹ 就變負數。沒有錯誤、沒有警告,只有算錯,>>> 0 把它轉回無號。這一條在明天會咬人,先記著。

壞掉③,會失效的那種。 wasm.ts 尾巴多一行 export const memory = wasm.memory; 給實驗用,然後開 DevTools 的 Console 貼這幾行(Vite 的 dev server 讓你直接 import 專案裡的檔):

const { memory } = await import('/src/lib/wasm.ts');
const old = memory.buffer;
const view = new Uint8Array(old);
memory.grow(1);
[view.byteLength, old.detached, memory.buffer === old]
[0, true, false]

grow 一頁之後,舊的 buffer 整個作廢,你手上所有建在它上面的 view 長度都變 0;而且不只你叫它才會長,alloc 要不到空間時它自己也會 grow。所以 view 一律用完即丟,alloc 之後再建,上面 hash 裡就是這個順序。

第三步:狀態住在哪

最後一件事不用 Rust,用昨天露過臉的文字格式手寫六行,wat/counter.wat

(module
  (global $count (mut i32) (i32.const 0))
  (func $inc (result i32)
    (global.set $count (i32.add (global.get $count) (i32.const 1)))
    (global.get $count))
  (export "inc" (func $inc)))

一個可變的 global,一支 inc 把它加一再回傳。wasm-tools parse wat/counter.wat -o src/lib/counter.wasm,76 B。wasm.ts 加一支:

import counterUrl from './counter.wasm?url';

// 每叫一次 newCounter 就是一顆全新的 counter。
export async function newCounter(): Promise<() => number> {
	const { instance } = await WebAssembly.instantiateStreaming(fetch(counterUrl), {});
	return instance.exports.inc as () => number;
}

頁面放兩顆按鈕:一顆叫 inc(),一顆重新 newCounter()。按 inc() 兩次:12。按「重新建一個 instance」再按 inc()1

狀態活在 instance,不在 module。 module 是那顆檔案,instance 是用它建出來的東西;同一顆檔案建十個 instance,就是十份各自的 count、十塊各自的記憶體,彼此看不到。這顆 counter 先放著,以後的篇章換宿主時會回來收。

結語

一句帶走:它跟外面只有一道邊界,三個方向都是你說了算:能力靠 import 表,少給一個就起不來;資料靠那塊 bytes 來回,view 用完即丟、u32 自己 >>> 0;狀態活在 instance,不在檔案裡。


上一篇
Day 03|.wasm 放進網頁前端
下一篇
Day 05|真的有加速嗎?JS 對決 .wasm
系列文
Tool Use Is All You Need:30 天用 WebAssembly 試圖控制 Agent 的手腳9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言